HMR(Hot Module Replacement) 热更新

webpack-dev-server 中实现 HMR 的核心就是 HotModuleReplacementPlugin.

特点:

  1. 可以实现局部更新,避免多余的资源请求
  2. 在更新的时候可以保存应用原有状态

热更新实现原理

  1. Webpack 编译时,为需要热更新的 entry 注入热更新代码 (EventSource 通信)。服务端与客户端通过 EventSource 建立通信渠道,把下一次的 hash 返回前端。客户端获取到 hash,这个 hash 将作为下一次请求服务端 hot-update.js 和 hot-update.json 的 hash 值
  2. 当修改了一个或多个文件
  3. 文件系统接收更改并通知 webpack
  4. webpack 重新编译构建一个或多个模块,编译完成后,发送 build 消息给客户端, 并通知 HMR 服务器进行更新:
    1. HMR Server 使用 webSocket 通知 HMR runtime 需要更新。
    2. HMR 运行时通过 HTTP 请求更新 jsonp。 客户端获取到 hash,成功后客户端构造 hot-update.js script 链接,然后插入主文档
    3. HMR 运行时替换更新中的模块,如果确定这些模块无法更新,则触发整个页面刷新 Page Reload。
      1. hot-update.js 插入成功后,执行 hotAPI 的 createRecord 和 reload 方法,获取到 Vue 组件的 render 方法,重新 render 组件, 继而实现 UI 无刷新更新。

底层实现

有两个关键的点:

  1. 与本地服务器建立「socket」连接,注册 hash 和 ok 两个事件,发生文件修改时,给客户端推送 hash 事件。客户端根据 hash 事件中返回的参数来拉取更新后的文件。
  2. HotModuleReplacementPlugin 会在文件修改后,生成两个文件,用于被客户端拉取使用。例如:

hash.hot-update.json

{
  "c": {
    "chunkname": true
  },
  "h": "d69324ef62c3872485a2"
}

chunkname.d69324ef62c3872485a2.hot-update.js,这里的 chunkname 即上面 c 中对于 key。

webpackHotUpdate("main",{
   "./src/test.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval(....)
  })
})

HMR 的核心就是客户端从服务端拉去更新后的文件,准确的说是 chunk diff (chunk 需要更新的部分),实际上 WDS 与浏览器之间维护了一个 Websocket,当本地资源发生变化时,WDS 会向浏览器推送更新,并带上构建时的 hash,让客户端与上一次资源进行对比。客户端对比出差异后会向 WDS 发起 Ajax 请求来获取更改内容(文件列表、hash),这样客户端就可以再借助这些信息继续向 WDS 发起 jsonp 请求获取该 chunk 的增量更新。

后续的部分(拿到增量更新之后如何处理?哪些状态该保留?哪些又需要更新?)由 HotModulePlugin 来完成,提供了相关 API 以供开发者针对自身场景进行处理。

详细过程

  1. webpack-dev-middleware 是用来处理文件打包到哪里,到内存读取速度更快。
  2. devServer 在监听 compiler done 后,利用 socket 告诉 devServer/client 修改模块的 hash
  3. HMR.runtime 利用 HTTP 请求 hash 和 hot-update.json 获取更新模块列表 hotDownloadManifest {"h":"11ba55af05df7c2d3d13","c":{"index-wrap":true}}
  4. 再通过 HTTP (jsonp) 获取更新模块的 js index-wrap.7466b9e256c084c8463f.hot-update.js

返回执行

webpackHotUpdate("index-wrap", {
  // ....
});

webpackHotUpdate 做了三件事

  1. 找到过期的模块和依赖并从缓存中删除
delete installedModules[moduleId];
delete outdatedDependencies[moduleId];
  1. 遍历所有的 module.children,重新 installedModules 所有的子模块
  2. 最后将自身模块的内容做替换修改
modules[moduleId] = appliedUpdate[moduleId];
  1. 最后代码替换之后并没有重新执行,需要手动注册需要重新执行的模块方法. HMR-Plugin 将热更新代码注入到 浏览器运行代码中,也就是 HRM runtime) HRM runtime 删除过期的模块,替换为新的模块,然后开始执行相关代码
if (module.hot) {
  module.hot.accept("./print.js", function () {
    console.log("Accepting the updated printMe module!");
    printMe();
  });
}

Webpack HMR 原理解析

1. webpack 对文件系统进行 watch 打包到内存中

webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销。

2. devServer 通知浏览器端文件发生改变

在启动 devServer 时服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器。

最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile 的 done 事件,当 compile 完成后,webpack-dev-server 通过 sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。

3. webpack-dev-server/client 接收到服务端消息做出响应

webpack-dev-server 修改了 webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会接收 websocket 消息的代码了。

webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作。

在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。代码如下:

  // webpack-dev-server/client/index.js
  hash: function msgHash(hash) {
      currentHash = hash;
  },
  ok: function msgOk() {
      // ...
      reloadApp();
  },
  // ...
  function reloadApp() {
    // ...
    if (hot) {
      log.info('[WDS] App hot update...');
      const hotEmitter = require('webpack/hot/emitter');
      hotEmitter.emit('webpackHotUpdate', currentHash);
      // ...
    } else {
      log.info('[WDS] App updated. Reloading...');
      self.location.reload();
    }
  }

4. webpack 接收到最新 hash 值验证并请求模块代码

首先 webpack/hot/dev-server监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新。

在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadManifesthotDownloadUpdateChunk。

hotDownloadManifest 是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端。该方法返回的是最新的 hash 值。

hotDownloadUpdateChunk 是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtime,HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。该 方法返回的就是最新 hash 值对应的代码块。

最后将新的代码块返回给 HMR runtime,进行模块热更新。

HMR

开启 HMR

对于 HMR 这种强大的功能而言,使用起来并不算特别复杂。接下来我们就一起了解一下如何去实现项目中的 HMR。

HMR 已经集成在了 webpack 模块中了,所以不需要再单独安装什么模块。

使用这个特性最简单的方式就是,在运行 webpack-dev-server 命令时,通过 --hot 参数去开启这个特性

或者也可以在配置文件中通过添加对应的配置来开启这个功能。那我们这里打开配置文件,这里需要配置两个地方:

  • 首先需要将 devServer 对象中的 hot 属性设置为 true;
  • 然后需要载入一个插件,这个插件是 webpack 内置的一个插件,所以我们先导入 webpack 模块,有了这个模块过后,这里使用的是一个叫作 HotModuleReplacementPlugin 的插件。

具体配置代码如下:

// ./webpack.config.js
const webpack = require('webpack')

module.exports = {
  // ...
  devServer: {
    // 开启 HMR 特性,如果资源不支持 HMR 会 fallback 到 live reloading
    hot: true
    // 只使用 HMR,不会 fallback 到 live reloading
    // hotOnly: true
  },
  plugins: [
    // ...
    // HMR 特性所需要的插件
    new webpack.HotModuleReplacementPlugin()
  ]
}

配置完成以后,我们打开命令行终端,运行 webpack-dev-server,启动开发服务器。那接下来你就可以来体验 HMR 了。

我们回到开发工具中,这里我们先来尝试修改一下 CSS 文件。样式文件修改保存过后,确实能够以不刷新的形式更新到页面中。

然后我们再来尝试一下修改 JS 文件。保存过后你会发现,这里的页面依然自动刷新了,好像并没有之前所说 HMR 的体验。

为了再次确认,你可以尝试先在页面中的编辑器里随意添加一些文字,然后修改代码,保存过后你就会看到页面自动刷新,页面中的状态也就丢失了,具体效果如下图:

js-live-reloading.gif

为什么 CSS 文件热替换没出现问题,而到了 JS 这块就不行了呢?我们又该如何去实现其他类型模块的热替换呢?

我们好像也没有手动处理样式模块的更新。因为样式文件是经过 Loader 处理的,在 style-loader 中就已经自动处理了样式文件的热更新,所以就不需要我们额外手动去处理了。

因为样式模块更新过后,只需要把更新后的 CSS 及时替换到页面中,它就可以覆盖掉之前的样式,从而实现更新。

而 JavaScript 模块是没有任何规律的,你可能导出的是一个对象,也可能导出的是一个字符串,还可能导出的是一个函数,使用时也各不相同。所以 Webpack 面对这些毫无规律的 JS 模块,根本不知道该怎么处理更新后的模块,也就无法直接实现一个可以通用所有情况的模块替换方案。那这就是为什么样式文件可以直接热更新,而 JS 文件更新后页面还是回退到自动刷新的原因。

一旦这个模块的更新被我们手动处理了,就不会触发自动刷新;反之,如果没有手动处理,热替换会自动 fallback(回退)到自动刷新。

平时使用 vue-cli 或者 create-react-app 没有手动处理,JavaScript 代码照样可以热替换, 是因为你使用的是框架,使用框架开发时,我们项目中的每个文件就有了规律,例如 React 中要求每个模块导出的必须是一个函数或者类,那这样就可以有通用的替换办法,所以这些工具内部都已经帮你实现了通用的替换操作,自然就不需要手动处理了。

综上所述,我们还是需要自己手动通过代码来处理,当 JavaScript 模块更新过后,该如何将更新后的模块替换到页面中。

常见问题

如果你刚开始使用 Webpack 的 HMR 特性,肯定会遇到一些问题,接下来我分享几个最容易发生的问题。

第一个问题,如果处理热替换的代码(处理函数)中有错误,结果也会导致自动刷新。例如我们这里在处理函数中故意加入一个运行时错误,代码如下:

// ./src/main.js
// ... 其他代码
module.hot.accept("./editor", () => {
  // 刻意造成运行异常
  undefined.foo();
});

直接测试你会发现 HMR 不会正常工作,而且根本看不到异常。这是因为 HMR 过程报错导致 HMR 失败,HMR 失败过后,会自动回退到自动刷新,页面一旦自动刷新,控制台中的错误信息就会被清除,这样的话,如果不是很明显的错误,就很难被发现。

在这种情况下,我们可以使用 hotOnly 的方式来解决,因为现在使用的 hot 方式,如果热替换失败就会自动回退使用自动刷新,而 hotOnly 的情况下并不会使用自动刷新。

module.exports = {
  devServer: {
    // 只使用 HMR,不会 fallback 到 live reloading
    hotOnly: true,
  },
};

第二个问题,对于使用了 HMR API 的代码,如果我们在没有开启 HMR 功能的情况下运行 Webpack 打包,此时运行环境中就会报出 Cannot read property 'accept' of undefined 的错误,具体错误信息如下:

image (2).png

除此之外,可能你还有一个问题:我们在代码中写了很多与业务功能本身无关的代码,会不会对生产环境有影响?

那这个问题的答案很简单,我通过一个简单的操作来帮你解答,我们回到配置文件中,确保已经将热替换特性关闭,并且移除掉了 HotModuleReplacementPlugin 插件,然后打开命令行终端,正常运行一下 Webpack 打包,打包过后,我们找到打包生成的 bundle.js 文件,然后找到里面 main.js 对应的模块,具体结果如下图:

image (3).png

你会发现之前我们编写的处理热替换的代码都被移除掉了,只剩下一个 if (false) 的空判断,这种没有意义的判断,在压缩过后也会自动去掉,所以根本不会对生产环境有任何影响。

webpack-dev-server 和 dev-middleware、hotMiddleware 的区别,原理能说说吗?

HotModuleReplacementPlugin 插件什么时候内置到 webpack 版本中的?

Last Updated:
Contributors: yiliang114